結論先說:capacity headroom、deadline、有限 retry、backoff 加 jitter 與 circuit breaker 必須一起設計;只加 retry,常會把一個下游的短暫故障放大成整個服務的排隊事故。degradation 的部分留到下篇。
一句話摘要:系統快撐不住時,最有價值的能力不是「再試一次」,而是知道哪一種請求值得等、哪一種應立即停下。
Day 28 把 prompt、model、index 與 tool schema 納入 change management。
今天處理另一個更狼狽的現場:版本沒有變,流量卻突然放大,或某個 dependency 開始變慢。
這時候 API 可能還會回 200。
但 worker 已經被等待中的請求塞住。
排隊變長。
timeout 接連發生。
retry 又把更多工作送回下游。
很快地,一個原本局部的問題就變成事故。
本文的實作內容是給讀者在隔離 DIY 環境自行操作的設計與程式碼。
我不會在這裡建立、安裝、啟動或驗證任何 DIY 專案,也不會碰 production dependency。
/ask假設服務處理一條企業政策問答流程:
POST /ask
↓
request admission
↓
retriever
↓
prompt builder
↓
primary model provider
↓
validator
↓
response
正常時,primary model 在 1.2 秒左右完成。
尖峰時,provider 的 p95 變成 6 秒。
如果 API handler 沒有 deadline,每個請求都會一直等。
同時進來的請求累積在 worker、connection pool 與 queue 裡。
接著有人看見 timeout,補了一段「失敗就 retry 三次」的程式。
事情沒有變好。
每個原始請求現在最多會製造四次下游呼叫。
若一百個人同時送出問題,provider 面對的不是一百個請求,而可能是四百個彼此重疊的請求。
這就是 retry amplification。
對 LLM workload 而言,放大的不只 QPS。
還有 token、排隊時間、connection、GPU slot,以及雲端帳單。
先把一條失敗鏈畫出來:
provider latency ↑
↓
application in-flight ↑
↓
worker / connection pool 被占住
↓
queue delay ↑
↓
client deadline 到期
↓
application retry ↑
↓
provider load 再次 ↑
↓
更多 timeout
這不是「程式碼寫得不夠積極」。
它是排隊系統的自然反應。
Google SRE 對 overload 的建議也把 load shedding、queue management 與明確的服務優先級放在一起看,而不是把所有工作都硬塞進後端。Google SRE Book:Handling Overload
很多容量討論從 CPU 70% 開始。
CPU 當然重要。
但對一個主要等待外部 provider 的 API,它往往不是第一個飽和點。
請求可能在等待網路、等待 connection、等待模型 queue,或等待某個同步 semaphore。
此時 CPU 看起來甚至很悠閒。
使用者卻已經在等。
在穩定狀態下,Little's Law 可寫成:
L = λ × W
L 是系統內平均工作數。
λ 是到達率。
W 是每件工作在系統停留的時間。
如果到達率不變,provider latency 從 1 秒拉到 8 秒,in-flight work 就會接近八倍。
不需要流量暴增,queue 也會爆。
這條公式不會替你選 timeout。
它會提醒你:latency 變差時,容量問題已經在發生,即使錯誤率還沒上升。
第一次接觸 Little's Law 的人,很容易把它當成一個只能套在「整個服務」上的巨觀公式:算出全站的 L、λ、W,得到一個總結性的數字,然後結案。
這是一種常見誤解。
Little's Law 對「任何一個穩定狀態的排隊子系統」都成立,不只是整條 /ask。
它可以套在整個 workflow,也可以分開套在每一段:
retriever 這一段
L_retriever = λ × W_retriever
primary model 這一段
L_model = λ × W_model
validator 這一段
L_validator = λ × W_validator
分開套用的價值在於:它會告訴你 in-flight work 具體堆在哪一段,而不是只告訴你「系統整體變擠了」。
如果只算整條 workflow 的 L,你只能看到一個總和;把公式拆進每一段之後,才看得出「原來 90% 的 in-flight work 堆在 model 呼叫這一段,retriever 幾乎沒有排隊」——這個資訊決定了你該去哪裡加 headroom,而不是整條路徑都無差別擴容。
傳統 Web API 的排隊模型通常假設「一個 worker 一次處理一個 request」,request 數與 worker 數線性對應。
GPU 推論不是這樣運作的。
多數推論服務會做 dynamic batching:GPU 不是一次跑一個 request,而是把短時間內到達的多個 request 合併成一個 batch 一起送進模型,藉此提高 GPU 利用率。
這件事對 Little's Law 的套用方式有具體影響:
傳統 API worker pool
in-flight ≈ 正在處理的 request 數
多一個 worker,大致多一分處理能力
GPU batch inference
in-flight 可能都卡在「等待湊滿一個 batch」
batch size 開太大 → 平均等待時間拉長,但吞吐量提高
batch size 開太小 → 延遲低,但 GPU 使用率低、單位成本高
也就是說,GPU 推論服務的 W(單一請求的停留時間)本身包含一段「等待被湊進 batch」的時間,這段時間跟後面模型真正計算的時間,是兩種完全不同性質的延遲來源——前者是排隊策略決定的,後者是模型大小與硬體決定的。如果只看整體 latency,你分不出這次變慢是「batch window 設太保守」還是「模型本身真的算得比較慢」。這正是為什麼今天表格裡把「dependency」與「應用程式」拆成兩層分開量測:GPU batching 這類排隊行為,往往藏在「dependency latency」這個籠統的數字底下,不會自己浮出來。
對今天的 /ask,先把容量觀察拆成四層:
| 位置 | 要量什麼 | 為什麼 |
|---|---|---|
| 入站 | request rate、reject count、client deadline | 判斷流量與是否已開始拒絕 |
| 應用程式 | in-flight、worker utilization、queue wait | 找到服務自己是否塞車 |
| dependency | latency、timeout、breaker state | 判斷慢的是誰 |
| 工作流 | retry attempts、fallback rate、task outcome | 判斷「成功」是否只是技術假象 |
只看 provider error rate 有一個盲點。
provider 變慢但還有回應時,error rate 可以接近零。
你的 user journey 仍然可能已經超時。
有人會說:「保留 30% headroom。」
它可以是起點,不能是答案。
真正需要回答的是:
若 autoscaling 需要五分鐘才拉起,而行銷活動在十秒內灌進流量,autoscaling 不是你第一道保護。
admission control、queue limit 與快取才是。
開始時,寧可誠實記錄假設:
workflow: policy_qa
observation_window: TODO
steady_request_rate_rps: TODO
observed_p95_latency_seconds: TODO
max_safe_in_flight: TODO
queue_wait_budget_seconds: TODO
primary_provider_limit: TODO
fallback_provider_limit: TODO
assumption_owner: TODO
TODO 不好看。
但它比填一個沒有量測來源的「100 RPS」可靠。
等到有隔離環境的 load test 與 trace,再用觀察值替換它。
傳統 HTTP dependency 的 retry 已經要小心。
AI workflow 多了幾層麻煩。
同一個 prompt 重送一次,通常會新增一次 input token 與 output token 計費。
若模型其實已經完成,只是 client 在回應路上超時,重送還可能產生兩份不同回答。
如果第二次回覆被接受、第一次晚到,trace 上會出現看似「成功」的兩次 call。
產品上則可能出現兩個不一致的結果。
讀取型 retriever query 通常能安全重試。
「建立付款」、「寄送通知」、「更新工單」這類 tool action 則未必能。
下游回 timeout 不代表它沒有執行。
它可能已經成功寫入,只是回覆丟了。
因此非冪等 action 的預設策略不是盲目重送。
而是:
確認 idempotency contract
↓
使用 idempotency key 或 query action status
↓
無法確認時停止,交由人工或明確標記 pending
把「沒有收到成功回覆」翻譯成「一定沒做」是事故常見的前半段。
「使用 idempotency key」不是打開一個開關,而是一份需要 client 與 server 共同遵守的合約;只加一個參數不會自動產生保護效果:
1. client 在第一次呼叫前,自行產生一組唯一 key(例如 UUID)
2. client 把這個 key 放進 request(header 或 body)
3. server 收到請求時,先查這個 key 是否已處理過
已處理 → 直接回傳當初的結果,不重新執行 action
未處理 → 執行 action,並把「key → 結果」存起來
4. client 因 timeout 重送同一個 request 時,帶的是同一把 key
server 因此能辨認「這是同一個意圖的重送」,不是新的一次付款
關鍵在於:idempotency key 保護的是「同一個使用者意圖只會被執行一次」,不是讓重送本身變安全。它至少需要三件事同時成立,缺一個都會失效:
如果這三個條件有一個你答不出來,就代表「idempotency key」目前只是名字,不是真正的保護,前面決策樹裡「確認 idempotency contract」這一步就還沒過。
今天的架構可以是:
Primary model
↓ unavailable / timeout
Fallback model
↓
validator
↓
degraded response or answer
fallback model 可能有不同的 context window、tool calling 格式、延遲與安全行為。
它也可能不能處理 primary model 專用的 prompt。
所以 fallback 需要自己的 evaluation cases,不是把 model 名稱改掉就叫高可用。
如果 fallback 沒有通過 source validation,正確結果可能是 insufficient_context。
不是硬生出一段聽起來流暢的回答。
以下兩條 trace 都可能 HTTP 200:
Trace A
attempts=1
tokens=900
task_status=completed
Trace B
attempts=4
tokens=4_100
task_status=failed
若 dashboard 只展示 request success rate,A 與 B 幾乎沒有差別。
若看 cost_per_completed_task,差別很大。
這是 Day 23 成本觀測與今天 resilience 設計要接起來的地方。
一個 API 故障期間,無防護的重試在短短 30 秒內消耗了 $2 的 token 成本;同樣的情境下,如果使用 circuit breaker 機制,成本只有 $0.01,相差 200 倍。
這個數字反映的不只是「重試多了 200 倍」,而是:TianPan.co:The Retry Storm Problem in Agentic Systems
失敗的工具呼叫(429 rate limit)
↓
盲目重試(每次重新送入 8,000 token 的完整上下文)
↓
4 個 LLM 呼叫 × 8,000 token = 32,000 input tokens
↓
用於完全「什麼都沒達成」的工作
vs.
失敗的工具呼叫
↓
Circuit breaker 立即開啟
↓
停止後續重試
↓
降級回應
無 circuit breaker 的系統,每一個失敗都變成了一個成本黑洞。這也是為什麼 Day 23 的成本監控與今天的 resilience 設計必須一起看:不是「降低成本」,而是「防止成本被失敗放大」。
寫 retry 前,先把錯誤分成可決策的類型。
HTTP status code 不夠。
同樣是 503,讀取型 retrieval 和付款 tool 的處理可能完全相反。
下面用純 Python 資料結構描述今天要保留的 decision context:
from dataclasses import dataclass
from enum import Enum
class OperationKind(str, Enum):
READ = "read"
WRITE = "write"
class FailureKind(str, Enum):
TIMEOUT = "timeout"
RATE_LIMIT = "rate_limit"
TRANSIENT_NETWORK = "transient_network"
VALIDATION = "validation"
AUTHORIZATION = "authorization"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class DependencyRequest:
request_id: str
operation_kind: OperationKind
idempotency_key: str | None
deadline_ms: int
@dataclass(frozen=True)
class RetryDecision:
should_retry: bool
reason: str
max_attempts: int
這段程式的重點不是 class 數量。
它是逼你把 operation_kind 與 idempotency_key 放進 decision 前提。
沒有這些資訊,generic retry decorator 只能猜。
def decide_retry(
request: DependencyRequest,
failure: FailureKind,
) -> RetryDecision:
if failure in {FailureKind.VALIDATION, FailureKind.AUTHORIZATION}:
return RetryDecision(False, "deterministic_failure", 1)
if request.operation_kind is OperationKind.WRITE:
if not request.idempotency_key:
return RetryDecision(False, "write_without_idempotency_key", 1)
return RetryDecision(False, "verify_write_status_first", 1)
if failure in {
FailureKind.TIMEOUT,
FailureKind.RATE_LIMIT,
FailureKind.TRANSIENT_NETWORK,
}:
return RetryDecision(True, "bounded_transient_retry", 2)
return RetryDecision(False, "unknown_failure", 1)
這個範例刻意保守。
它對 write action 回傳 verify_write_status_first,沒有直接 retry。
實際產品可以在 idempotency contract 完整時做不同選擇。
但那應由 action owner 明確決定,不能藏在共用 helper 裡。
表格中第二行「rate limit」背後有一個經常被低估的現實。在生產環境中,429(Too Many Requests)是團隊規模超過原型階段後最常見的故障。每個主流 LLM provider(OpenAI、Anthropic、Google、AWS Bedrock)都強制施加嚴格的 requests-per-minute 與 tokens-per-minute 限制。
許多團隊的直覺反應是「加上 exponential backoff 重試」。但這樣解決的只是 symptom,不是根本問題。真正的生產策略應該是:
Primary provider 回 429
↓
立即檢查 Retry-After header
↓
超過預算? → 故障轉移至 fallback provider
↓
Fallback 也 429? → 降級回應,不再重試
根本差異在於:naive retry 假設「過一陣子就好了」,但在高流量下,重試會進一步加劇 provider 的過載。正確做法是在 API gateway 層(而非應用層)實施 per-tenant rate limiting,防止單一客戶的突發耗盡整個共享配額,或採用多 API key 負載平衡,讓每個 key 保持在配額上限以下。
這個架構決策不在 retry logic 裡,而在 routing 層。
| 失敗 | read query | write action | 使用者結果 |
|---|---|---|---|
| connect timeout | 有 deadline 的有限 retry | 查詢 action status | 暫時不可用或 pending |
| rate limit | 尊重 retry-after 或受控退避 | 不重送未知結果 | 請稍後再試 |
| validation error | 不 retry | 不 retry | 明確指出輸入問題 |
| authorization error | 不 retry | 不 retry | 拒絕,保留 audit event |
| empty retrieval | 不 retry model 來「猜」 | 不適用 | insufficient_context |
| schema mismatch | 不 retry 同一 payload | 不執行 action | 安全拒絕與人工處理 |
表裡的使用者結果必須由產品接受。
「我們不知道要回什麼」不是 engineering 可以用 HTTP 500 解決的問題。
timeout 經常被當成一個常數。
實際上它至少有三個層次:
client deadline
> service deadline
> dependency timeout
外層必須比內層長一點,讓服務還有時間整理降級結果、寫必要的 log 或停止後續工作。
若 dependency timeout 比 service deadline 長,service 已經放棄,底下的呼叫卻還在跑。
那是背景資源洩漏的溫床。
from dataclasses import dataclass
from time import monotonic
@dataclass(frozen=True)
class Deadline:
expires_at: float
@classmethod
def after_ms(cls, timeout_ms: int) -> "Deadline":
return cls(expires_at=monotonic() + timeout_ms / 1_000)
def remaining_ms(self) -> int:
remaining = int((self.expires_at - monotonic()) * 1_000)
return max(0, remaining)
def dependency_timeout_ms(deadline: Deadline, reserve_ms: int) -> int:
remaining = deadline.remaining_ms() - reserve_ms
if remaining <= 0:
raise TimeoutError("service deadline exhausted")
return remaining
這個 helper 留一段 reserve_ms。
保留時間不是拿來做更多 retry。
它是讓 application 能取消後續步驟、產生受控回覆與記錄 outcome。
client timeout = 5 s
API timeout = 5 s
retriever timeout = 5 s
model timeout = 5 s
看起來整齊。
實際上沒有任何收尾空間。
當 model 花滿五秒才丟錯,API 很可能已經沒時間做 validation 或 degradation。
每層都用同一個數字,也使得 trace 很難看出到底誰先耗掉預算。
當 client 斷線或 deadline 到期,application 應盡可能取消還在等待的工作。
取消不是失敗的同義詞。
它是資源保護策略。
請分開記錄:
technical_status=cancelled
workflow_status=aborted_by_deadline
quality_status=not_evaluated
task_status=not_completed
把 cancelled 都算 500,會讓 error rate 很難解釋。
把 cancelled 都算 success,則會把被放棄的使用者旅程洗掉。
最直覺的 retry 看起來像這樣:
for attempt in range(3):
try:
return call_dependency()
except TimeoutError:
sleep(1)
raise RuntimeError("dependency unavailable")
問題在於大量請求會同時失敗,也會在一秒後同時重試。
下游剛想喘口氣,下一波又整齊地撞上去。
這種同步重試通常被稱為 thundering herd。
AWS 對 timeout、retry 與 backoff 的說明特別提醒:retry 要有限,並用 jitter 避免客戶端在同一時間重送。AWS Builders' Library:Timeouts, retries, and backoff with jitter
from random import uniform
def backoff_seconds(
attempt_number: int,
*,
base_seconds: float = 0.2,
cap_seconds: float = 2.0,
) -> float:
exponential_cap = min(
cap_seconds,
base_seconds * (2 ** attempt_number),
)
return uniform(0, exponential_cap)
attempt_number 從零開始時,第一個等待落在 0 到 0.2 秒。
第二次等待落在 0 到 0.4 秒。
它不保證更快。
它的目的,是讓大量 client 的重試分散。
到這裡容易產生一個誤解:以為 jitter 只是「重試專用的裝飾」,只要 retry loop 裡有 sleep(random) 就算做到了。
實際上 jitter 要處理的病根,是「大量獨立單位因為共用同一種計時規則,在時間軸上被壓成同一個瞬間」,重試只是最常見的一種觸發方式,不是唯一一種。
Clerk 在 2025 年 9 月的一次資料庫事故,正好是這個病根在完全不涉及 retry logic 的地方發作的例子。Clerk 官方 Postmortem:Database Incident (September 14–18, 2025)
事情的起點跟重試毫無關係。Clerk 執行在 Cloud Run 上,容器對外部連線設定了固定 15 分鐘的生命週期。多年來,PostgreSQL 供應商那邊有一個從未被寫進任何設計文件的效能缺陷:連線授予(lock manager 的鎖授予邏輯)帶有 O(n²) 的時間複雜度——沒有人設計它去做 rate limiter,但它長期意外扮演了這個角色,讓原本應該同步到期的連線,實際上從來沒有真正同時過期過。
9 月 14 日,資料庫供應商修好了那個效能缺陷。連線授予變成幾乎瞬間完成。那個從未存在於文件裡的隱性節流,就這樣被拿掉了。
接下來的連鎖反應,跟今天前面畫的那條失敗鏈長得幾乎一模一樣,只是把「provider timeout」換成「固定生命週期的連線」:
連線授予變快(隱性節流消失)
↓
所有容器的連線,因為生命週期都固定 15 分鐘
↓
開始同步過期(bus bunching)
↓
每 15 分鐘,所有容器同時向資料庫要求新連線
↓
資料庫鎖競爭瞬間飆高
↓
API 請求失敗與延遲飆升
團隊在 9 月 17 日做了一次 query 優化,尖峰一度完全消失——他們以為問題解決了。結果一小時後,同一個「每 15 分鐘一次」的尖峰又規律地重現。真正的根因,直到 9 月 18 日才被定位到:不是 query 效能,是連線生命週期的同步。
最終修復方式,正是本文一直在講的東西:在連線池策略中加入 jitter,把原本整齊劃一的 15 分鐘生命週期打散,讓連線的過期時間點分散在一個時間窗內,而不是全部命中同一秒。
這個案例對今天要傳達的觀念有兩點補充:
from time import sleep
def call_with_retry(request, deadline, client):
last_error = None
for attempt in range(2):
try:
timeout_ms = dependency_timeout_ms(deadline, reserve_ms=100)
return client.call(timeout_ms=timeout_ms)
except TimeoutError as error:
last_error = error
delay = backoff_seconds(attempt)
if deadline.remaining_ms() <= int(delay * 1_000) + 100:
break
sleep(delay)
raise RuntimeError("bounded retry exhausted") from last_error
這個範例故意把上限寫成兩次總嘗試。
不是「先試一次再 retry 兩次」。
命名與 metric 也應採用同一個定義,避免 incident 時有人把 attempts=3 理解成總共三次,另一些人理解成三次重試。
以下狀況通常不會因為重送同一請求而變好:
把這些送回 retry loop,只是把決策往後拖。
circuit breaker 把 dependency 的近期失敗轉成一個明確狀態。
最常見的狀態圖是:
closed
↓ failure threshold crossed
open
↓ cooldown elapsed
half_open
├─ probe succeeds → closed
└─ probe fails → open
closed 是正常呼叫狀態。
open 時不再把新請求送到已知有問題的 dependency。
half_open 只讓少量 probe 通過,確認下游是否恢復。
Microsoft 的 circuit breaker pattern 文件也把 fail fast 與防止對失敗服務持續呼叫視為這個模式的核心。Microsoft Architecture Center:Circuit Breaker pattern
half_open 容易被想成「試著再打一次」,但它要處理一個具體風險:dependency 剛恢復時最脆弱,若一次放行所有排隊請求,等於讓一群等了十秒的 client 同時撞上一個容量還沒回穩的服務——這是同一種 thundering herd,只是發生在 breaker 從 open 轉回 closed 的那一瞬間,而不是重試迴圈裡。
正確語意是:只放行極少數 probe(例如一次一個,成功後才逐步增加),其餘請求仍被擋著,直到 probe 證明下游撐得住。上面簡化版的 CircuitBreaker 沒有實作這個限制——allow_call() 一進入 HALF_OPEN 就對所有呼叫回傳 True,這也是它被列進「省略清單」的原因,production 版本必須補上。
不要讓一個 tenant 的壞輸入把全站 breaker 打開。
也不要把每一個 request 都做成獨立 breaker,最後永遠累積不到訊號。
合理 scope 可能是:
provider + model route + region
或:
retrieval service + index version
scope 是架構決定。
它要由 dependency 的故障域與流量隔離方式決定。
這段只適合讓讀者理解狀態轉換。
多 instance production 需要共享或一致的狀態設計,或選擇能容忍各 instance 局部判斷的策略。
from dataclasses import dataclass
from enum import Enum
from time import monotonic
class BreakerState(str, Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class CircuitBreaker:
failure_threshold: int = 3
reset_after_seconds: float = 10.0
failures: int = 0
opened_at: float | None = None
def state(self) -> BreakerState:
if self.opened_at is None:
return BreakerState.CLOSED
if monotonic() - self.opened_at >= self.reset_after_seconds:
return BreakerState.HALF_OPEN
return BreakerState.OPEN
def allow_call(self) -> bool:
return self.state() is not BreakerState.OPEN
def record_success(self) -> None:
self.failures = 0
self.opened_at = None
def record_failure(self) -> None:
self.failures += 1
if self.failures >= self.failure_threshold:
self.opened_at = monotonic()
這個版本省略了幾件 production 必要的事:
省略它們不是代表不重要。
而是今天先把行為契約講明白。
很多人第一次實作 circuit breaker,寫出來的其實是一個帶記憶的 retry decorator:「連續失敗幾次就多睡一下再試」。這跟真正的 circuit breaker 差在一個關鍵動作:breaker open 時,呼叫根本不會發生。
retry with backoff(沒有 breaker)
每次呼叫都真的打到 dependency,只是失敗後等久一點
circuit breaker open
呼叫在本地就被擋下來,dependency 完全收不到這次請求
dependency 已經過載時,這個差異特別重要:retry 加大 backoff 只是讓敲門頻率變低,dependency 收到的請求量還在;breaker open 之後,敲門這件事本身停止,dependency 得到的是真正的喘息空間。Google SRE 談 cascading failure 時,也把「讓後端有機會恢復」列為關鍵手段之一。Google SRE Book:Addressing Cascading Failures
上面的範例把 failures 與 opened_at 存成 process 內欄位。教學範例裡沒問題,但放進十個 replica 的生產服務時:
10 個 instance 各自維護 breaker state
↓
dependency 開始失敗
↓
每個 instance 要各自累積到 failure_threshold 才會 open
↓
在全部 open 之前,dependency 實際承受的失敗嘗試
可能是單機門檻的 instance 數倍
每個 instance 都「正確地」按照自己的規則行動,集體行為卻不是設計者想要的「快速停止」——單機 breaker 的 failure_threshold=3,在十個 instance 的集群裡實際等於 3 × 10 這個量級。
這不代表單機 breaker 沒有價值,它仍比完全沒有好,是最小可行的起點。但若故障域是跨所有 instance 共用(例如同一個 provider endpoint),breaker 狀態理想上也該共用——常見做法是放進 Redis,讓集群讀到同一個 open/closed/half-open 狀態。這正是「省略清單」裡「跨 process 的狀態」真正代表的風險:決定 breaker 保護力道是除以 1 還是除以 instance 數。
這是最常被忽略的半邊。
breaker 擋下 downstream call 後,API 還是得回覆使用者。
可能的選項如下:
| 情境 | 可接受的 degradation | 不可接受的 degradation |
|---|---|---|
| 政策問答且有版本明確的快取 | 回快取並標示資料版本與時間 | 把舊資料說成最新規範 |
| 無可靠來源可查 | insufficient_context 或暫時不可用 |
讓模型憑印象補答案 |
| 低風險摘要 | 排隊、稍後通知或較短回答 | 靜默丟失請求 |
| 寫入型 tool action | 停止並顯示 pending / human review | 重送未知狀態的 action |
| 安全審查 dependency 失效 | 拒絕高風險 action | 因安全服務故障而放行 |
degradation 的設計要讓使用者知道系統做了什麼。
不是把原本完整回答換成語氣更堅定的猜測。
沒有 circuit breaker 的成本是什麼樣的?曾有一個以 LangChain 打造的多 agent 系統,其中兩個 agent 互相詢問同樣的問題,每次都失敗、每次都重試,循環了 264 小時,累積成本達 $47,000。awesome-agent-failures:LangChain A2A 47K infinite loop 案例
每次失敗看起來都只是「不確定答案,問問別人」,但沒有 circuit breaker 時,這句話變成了自動迴圈。若有一個簡單的 breaker 在失敗 3 次後打開,成本會在最初幾十次失敗時就停止——circuit breaker 不只是技術細節,是成本保險。
今天的實驗不需要真的切斷 provider。
先做一個可重現的 fake client。
前面幾天的故障注入(Day 3 的 Chaos)已經示範過對自己控制範圍內的服務(自家 API、自家資料庫容器)做真的故障注入。今天的對象不一樣:primary model provider 是第三方服務,讀者沒有辦法、也不應該去讓它真的變慢或斷線。
這帶出一個今天特別重要的設計原則:resilience 邏輯本身的正確性,跟它保護的那個 dependency 是誰,應該是兩件可以分開驗證的事。 deadline 有沒有正確倒數、backoff 有沒有加上 jitter、breaker 有沒有在對的次數後轉成 open——這些問題的答案不需要真的問過 OpenAI 或 Anthropic 才能知道。只要能可靠地製造出「慢」與「失敗」這兩種行為,就能把 resilience 邏輯本身測到位。
FakeModelClient 存在的意義正是這個:它是一個行為可控制的替身,讓「今天寫的 deadline、retry、breaker 邏輯到底對不對」這個問題,跟「Gemini 或 GPT 今天心情好不好」這個問題完全脫鉤。
讀者可以在自己的隔離 DIY 專案加入:
from dataclasses import dataclass
from time import sleep
@dataclass
class FakeModelClient:
latency_seconds: float = 0.0
fail_with_timeout: bool = False
def call(self, *, timeout_ms: int) -> dict[str, str]:
sleep(self.latency_seconds)
if self.fail_with_timeout:
raise TimeoutError("simulated provider timeout")
if self.latency_seconds * 1_000 > timeout_ms:
raise TimeoutError("simulated client deadline")
return {"answer": "simulated grounded answer"}
這個 fake client 的價值在於可控制。
同一條測試可以重複得到 timeout。
不用賭外部 API 剛好壞掉。
也不會花 token。
latency_seconds 模擬第一段「primary model 尖峰時 p95 變成 6 秒」的現象,只是換成讀者可以自己指定的秒數;fail_with_timeout 模擬更極端的狀況:provider 直接連線失敗,而不是「有回應,只是慢」。
兩者分開設計,對應⑥段落「慢但最終會回應」與「根本連不上」這兩種不同的 retry 情境——只測 fail_with_timeout 會漏掉「慢但還在跑」這種更常見、也更難處理的情境,它不會讓 retry loop 立刻失敗,而是讓 in-flight work 持續累積,直到上層 deadline 耗盡才被迫中止。
SCENARIOS = {
"healthy": {
"latency_seconds": 0.05,
"fail_with_timeout": False,
},
"slow_but_within_deadline": {
"latency_seconds": 0.15,
"fail_with_timeout": False,
},
"deadline_exhausted": {
"latency_seconds": 0.50,
"fail_with_timeout": False,
},
"hard_timeout": {
"latency_seconds": 0.01,
"fail_with_timeout": True,
},
}
這四種情境的預期不可相同。
| Scenario | 預期 technical status | 預期 workflow 結果 | 預期 breaker |
|---|---|---|---|
| healthy | success | completed | closed |
| slow but within deadline | success | completed,latency 升高 | closed |
| deadline exhausted | timeout | degraded 或 not completed | 依連續失敗情況 |
| hard timeout | timeout | bounded retry 後降級 | 可能 open |
如果所有 case 都只 assert status_code == 200,你沒有測到 resilience。
把下列內容放進自己的測試或 smoke script 時,可以先用 print 觀察 decision。
def run_scenario(name: str, config: dict[str, object]) -> None:
client = FakeModelClient(**config)
deadline = Deadline.after_ms(300)
try:
result = call_with_retry(
request=None,
deadline=deadline,
client=client,
)
print({"scenario": name, "result": result, "outcome": "completed"})
except RuntimeError as error:
print(
{
"scenario": name,
"error": str(error),
"outcome": "degraded_or_failed",
}
)
for scenario_name, scenario_config in SCENARIOS.items():
run_scenario(scenario_name, scenario_config)
這份範例不是 production client。
它沒有網路 cancellation、async scheduling、distributed breaker 或 provider SDK error mapping。
先用它驗證「選擇什麼結果」;再把相同 contract 對應到自己的框架。
接上前面的 Deadline.after_ms(300)(300 毫秒 service deadline),四個 scenario 的預期行為:
healthy(50ms)→ completed,遠低於 deadline,剩餘時間充裕。slow_but_within_deadline(150ms)→ completed,但已逼近上限;deadline 收緊到 200ms 就會翻成 degraded。deadline_exhausted(500ms)→ degraded;dependency_timeout_ms() 呼叫前就算出剩餘時間不夠,直接拋出 TimeoutError,不會真的等滿 500ms。hard_timeout(10ms,fail_with_timeout=True)→ degraded;每次呼叫都在 10ms 內失敗,bounded retry 嘗試兩次後才拋出 RuntimeError。把程式碼接上自己的 DIY 專案執行時,這幾個地方容易被誤以為是 bug,其實是刻意設計:
deadline_exhausted 不會真的等 500 毫秒——dependency_timeout_ms() 呼叫前就會檢查剩餘 deadline,不夠就直接拋錯,這正是⑤段落「reserve_ms 讓系統提早放棄」的實際行為。reset_after_seconds 預設 10 秒);要實際觀察到,需要刻意重複呼叫並等待,下篇的 failure injection 步驟會把它獨立處理。OperationKind.READ;寫入型 action 沒有安全的 fake 版本可示範,因為「確認 idempotency contract」需要一個真正有狀態的 server 端配合。上篇處理完 capacity、deadline、retry 與 circuit breaker 的行為契約。下篇會把技術結果翻譯成誠實的 API response、補齊 metrics/logs/traces 的 resilience 觀測、定義 SLI 與 admission control,並帶讀者走一次完整的 failure injection 操作流程。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.